Skip to content

Add Bitbucket adapter - #125

Open
HarshMN2345 wants to merge 45 commits into
mainfrom
feat-bitbucket-adapter
Open

Add Bitbucket adapter#125
HarshMN2345 wants to merge 45 commits into
mainfrom
feat-bitbucket-adapter

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Jul 29, 2026

Copy link
Copy Markdown
Member

Adds a Bitbucket Cloud adapter (API 2.0), implementing every abstract on Adapter/Git: repositories, source/tree/content, branches, tags, commits, build statuses, pull requests, comments, webhooks, clone commands and webhook event parsing. Authenticates with a Bearer access token (OAuth 2.0, workspace or repository token), passed through initializeVariables(accessToken:) like the GitLab and Gitea adapters.

Endpoint shapes were taken from Bitbucket's published OpenAPI spec, including the POST /src form-field contract (file paths are sent as /-prefixed field names so a file named message isn't read as commit metadata) and its documented empty-repo / new-branch behavior.

Where Bitbucket doesn't fit the shared interface

Each of these is implemented and documented in place:

  • No numeric repository ids. id is normalized to workspace/slug, which is what its API routes on and what getRepositoryName() accepts. getOwnerName() therefore ignores $repositoryId and resolves the token's own workspace.
  • Webhooks are UUID-keyed, so Git::createWebhook() now returns int|string and deleteWebhook() takes what it returned.
  • No language statistics — only a hand-set language field, reported when present.
  • Push payloads carry no file lists, so affectedFiles is always empty.
  • Archive downloads come from the browser host, not the API host, and are answered directly rather than redirected to a signed URL, so getRepositoryPresignedUrl() embeds the credential as HTTP basic userinfo. GitHub returns its redirect target instead, and the ?access_token= query form GitLab and Gitea use was removed from Bitbucket in CHANGE-3052.

Responses are normalized onto the keys the other adapters report: private, pushed_at, number on pull requests, lowercased PR state, and commit states mapped both ways between Bitbucket's vocabulary and the shared one.

Tests

BitbucketTest follows the pattern main now uses: the shared contract lives in Base, and an adapter's own file declares what it is and only tests what is true of it alone. It declares 7 tests of its own and runs 108 in total — 101 of them inherited.

Bitbucket declares the parts of the contract it does not offer, rather than overriding tests to say so: $supportsCheckRuns, $supportsNamespaceListing, $supportsRepositoryLanguages, $supportsUserLookup, $supportsWebhookDelivery, $supportsInstallationRepository, $resolvesOwnerFromRepositoryId and $reportsAffectedFilesInPushEvent. Those skip the shared tests for the parts it does not offer.

What stays Bitbucket's own: workspaces (its grouping in place of namespaces), UUID-keyed webhooks, user lookup by UUID, multi-ref pushes through getEvents(), its event-to-action mapping, tag pushes not being reported as branches, the linked-vs-raw commit author, and a build status written without a URL. The hand-written getEvent assertions are gone — the class supplies pushPayload() and pullRequestPayload() builders and the shared assertions cover them.

Three additions to Base, all defaulting to full support so the gap is the adapter's to declare:

  • $supportsPresignedUrls and $reportsAffectedFilesInPushEvent, new capability flags (Bitbucket declares only the latter -- it does support presigned urls).
  • repositoryIdOf(), overridable, because Base otherwise asserts a repository id is numeric.
  • EVENT_* payload facts are read through static:: so an adapter can restate one; Bitbucket restates the repository id as workspace/slug.

testWebhookPullRequestEvent also now skips on $supportsWebhookDelivery rather than only on pull request support, which is what actually stops Bitbucket Cloud from reaching the local request-catcher.

One adapter fix came out of running the shared tests: getEvent() returned an empty event for a malformed payload instead of throwing, as the other adapters do.

composer lint and composer check (PHPStan level 8) pass. The suite is still credential-gated and the secrets are not set on this repo, so the bitbucket CI job currently skips all 112 tests and passes without asserting anything — it needs TESTS_BITBUCKET_ACCESS_TOKEN, and optionally TESTS_BITBUCKET_WORKSPACE, whose workspace needs at least one project since Bitbucket assigns every new repository to one.

Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a Bitbucket Cloud adapter and extends the shared VCS contract to support provider-specific webhook identifiers and batched webhook events.

  • Implements Bitbucket repository, source, branch, tag, commit, pull-request, status, webhook, archive, clone, and event operations.
  • Adds Bitbucket integration tests, CI configuration, capability flags, and documentation.
  • Moves webhook creation and deletion into the common adapter contract and introduces getEvents() for batched deliveries.

Confidence Score: 2/5

This PR is not safe to merge until webhook recovery stops returning identifiers from incomplete listings and the outstanding webhook event-loss, orphaning, and credential-exposure failures are addressed.

A failed later webhook-list page can make UUID recovery select the wrong hook, unresolved UUID recovery can leave active hooks unmanaged, getEvent still discards later refs in multi-ref pushes, and archive and clone outputs still expose the access token.

Files Needing Attention: src/VCS/Adapter/Git/Bitbucket.php

Important Files Changed

Filename Overview
src/VCS/Adapter/Git/Bitbucket.php Implements the Bitbucket adapter, but webhook UUID recovery can trust an incomplete listing and several previously reported webhook and credential-handling failures remain.
src/VCS/Adapter.php Extends the shared contract with string webhook identifiers, deletion, and batched event parsing.
tests/VCS/Adapter/BitbucketTest.php Adds Bitbucket-specific contract coverage, though credential-gated execution and skipped webhook delivery leave important remote behavior unverified.
tests/VCS/Base.php Generalizes shared adapter tests for opaque repository and webhook identifiers, capability flags, and batched-event differences.
src/VCS/Adapter/Git/GitHub.php Adds webhook deletion required by the expanded shared adapter contract.
src/VCS/Adapter/Git/GitLab.php Adds webhook deletion required by the expanded shared adapter contract.
src/VCS/Adapter/Git/Gitea.php Adds webhook deletion inherited by Gitea-compatible providers.

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
src/VCS/Adapter/Git/Bitbucket.php:1035-1037
**Partial hook list selects wrong UUID**

When a later page of the hook listing fails after an earlier page contains one matching URL, this branch treats that partial result as complete and returns the older hook's UUID. Subsequent cleanup deletes the older webhook while the newly created webhook remains active and unmanaged.

```suggestion
            if (($responseHeaders['status-code'] ?? 0) >= 400) {
                return null;
            }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (26): Last reviewed commit: "fix: return the routable slug and a real..." | Re-trigger Greptile

@HarshMN2345

Copy link
Copy Markdown
Member Author

@greptile fixed the first three in aaa8801.

  1. repositoryId was inconsistent, good catch. A bitbucket repo uuid isn't routable on its own (the api wants workspace + repo in the path), so getEvent now reports full_name, which is what getRepositoryName and the normalized id already use. Added a test that pulls the id out of the event and feeds it straight into getRepositoryName so the two can't drift apart again.

  2. Agreed, creating the hook and then returning 0 was the worst of both. createWebhook now throws before creating anything and points at createRepositoryWebhook, which returns the uuid that deleteWebhook takes. Same approach as GitHub::createTag. Widening the interface to return string would be nicer but it touches all 5 adapters and their tests, so I'd rather do that separately if we want it.

  3. Added getEvents(), which returns one event per ref in payload order. getEvent() returns the first so the shared interface doesn't change. While doing this I noticed tags arrive in the same changes array and were being reported as branches (branch = v1.0.0, url pointing at /branch/v1.0.0), so those are skipped now and a tag only push returns [].

  4. Not changing this one here. The token in the clone url is how every adapter in this library already works, GitLab embeds oauth2:{token}@ and Gitea embeds {owner}:{token}@, and Base::testGenerateCloneCommand asserts that command shape. Making bitbucket the only one that does it differently wouldn't remove the exposure anywhere else. If we want GIT_ASKPASS or a credential helper it should be one change across all the adapters.

Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

All three fixes look correct from the diff and tests.

1 — repositoryId: getEvent() now reports full_name (workspace/slug) and testGetEventReportsResolvableRepositoryId closes the loop by feeding the emitted id straight into getRepositoryName(). Solid.

2 — createWebhook: Throwing before creating anything is the right call. The testCreateWebhookThroughSharedInterfaceIsRefused / testCreateWebhook pairing makes the intended path unambiguous — callers get a clear error if they hit the wrong entry point.

3 — getEvents() + tag skipping: The multi-ref test (2 branches + 1 tag → 2 events) and the tag-only test ([]) cover both cases cleanly. Having getEvent() delegate to getEvents()[0] rather than duplicating the parsing logic is the right structure.

4 — Clone URL: Fair point, and I agree it should be a single cross-adapter change if pursued. Happy to drop that comment.

No remaining blockers from my side on these four items.

Bitbucket identifies a hook by UUID, which an int return cannot carry, so
createWebhook() threw and a separate createRepositoryWebhook() returned the
UUID. Widening the return to int|string lets Bitbucket implement the same
method every other adapter does.
Bitbucket declares the parts of the contract it does not offer - presigned
archive urls, check runs, namespace listing, computed languages, user
lookup by handle, webhook delivery to the catcher, and file lists in a
push event - and keeps only the tests describing itself: workspaces, uuid
webhooks and user lookups, multi-ref pushes, and its action mapping.

The event payload builders replace hand-written getEvent tests, so the
shared assertions cover them, and Base's repository id is now overridable
because Bitbucket routes on "workspace/slug" rather than a numeric id.

getEvent() returned an empty event for a malformed payload rather than
throwing, as the other adapters do.
@HarshMN2345
HarshMN2345 force-pushed the feat-bitbucket-adapter branch from aaa8801 to fe8e115 Compare July 30, 2026 11:26
@HarshMN2345

Copy link
Copy Markdown
Member Author

@greptile This matches the existing pattern in GitHub::generateCloneCommand() and GitLab::generateCloneCommand() both embed the access token in the git remote add origin URL the same way, so this isn't a regression specific to Bitbucket. Fixing it properly (e.g. via a credential helper instead of an embedded URL) would need to happen across all three adapters together worth tracking as a separate follow-up rather than a Bitbucket-only patch here.

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

That's a fair point. The token-in-URL pattern is consistent across GitHub, GitLab, and now Bitbucket — this PR isn't introducing anything new. A proper fix (credential helper, GIT_ASKPASS, or similar) would need to be a coordinated change across all three adapters to avoid inconsistency.

Agreed — worth tracking as a repo-level follow-up rather than blocking here.

@HarshMN2345

Copy link
Copy Markdown
Member Author

Re the two Greptile findings:

Clone command exposes the access token — this matches the existing pattern in GitHub::generateCloneCommand() and GitLab::generateCloneCommand(), both of which embed the token in the git remote add origin URL the same way. It's not a Bitbucket-specific regression; fixing it properly (e.g. via a credential helper instead of an embedded URL) would need to happen across all three adapters together, so I'd track that as a separate follow-up rather than a Bitbucket-only patch here.

getEvent() drops later branch changes — this one was real. Bitbucket::getEvent() intentionally reports only the first of a batched push (getEvents() has the rest), but getEvents() wasn't part of the shared Adapter contract, so a consumer coded against the standard interface would silently lose every branch after the first. Fixed in 2ffe0a4 by promoting getEvents() onto Adapter with a default that wraps getEvent():

public function getEvents(string $event, string $payload): array
{
    return [$this->getEvent($event, $payload)];
}

Every other adapter gets this for free with no behavior change; Bitbucket already overrides it correctly.

Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
Same bug as the OAuth2 side, independently: username/nickname aren't
workspace identifiers for accounts migrated to Atlassian's unified
identity, and the account UUID doesn't double as the workspace UUID.
Silently returned zero repositories rather than an error, since
searchRepositories() treats any 4xx as an empty result.

Resolves the workspace via /user/workspaces -- the endpoint
Atlassian's migration guidance names as the replacement for the
cross-workspace /workspaces listing CHANGE-2770 removed -- falling
back to the old username/nickname behavior only if that call fails.
resolveRef() above already catches Exception and converts it to
FileNotFound() for the expected "file/ref doesn't exist" case, but the
two call() invocations below it didn't -- so a 404 whose response
body doesn't decode as JSON (a real, common case: not every repo has
package.json) propagated as an uncaught fatal, crashing the whole
Swoole worker (confirmed live) rather than resulting in a normal
FileNotFound for this one lookup.
Was missing entirely, throwing the base Git class's "not supported"
default -- confirmed live, this crashed VCS site/function deployments
outright (Compute/Base.php calls it unconditionally for every
provider). Bitbucket's archive download lives on the browser host,
not the API host, and only supports zip/gz/bz2 (no "tarball"
extension distinct from gz). Auth is URL-embedded the same way
generateCloneCommand() already does it, since this URL is handed off
for a plain download rather than called with a bearer header.
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
…igned urls

Follow-ups from review of the presigned-url commit:

- Archive extension was `.gz`; verified against a live repo that
  `.tar.gz` serves the same gzipped tarball and matches the shared
  contract's default $presignedTarballFragment, so no override is
  needed.
- Ref is now encoded keeping slashes, as Gitea and GitHub do, so
  nested branch names (feature/foo) resolve.
- Documented why the credential travels as basic userinfo rather than
  the query parameter GitLab and Gitea use: Bitbucket answers this
  directly instead of redirecting to a signed url (so GitHub's
  redirect-following approach isn't available), and its
  ?access_token= form was removed in CHANGE-3052.
- createWebhook() threw away a missing uuid, returning '' and leaving
  the caller unable to delete the hook it just created. Now throws,
  mirroring GitHub::createWebhook()'s guard.
- Dropped $supportsPresignedUrls = false so the two shared contract
  tests run instead of asserting the method throws -- it no longer
  does. Folded the tag-only push case into the multi-ref test rather
  than keeping a separate test for the same rule.
The method name collided with Base::testGetUser(), so PHPUnit ran only
Bitbucket's override and never reached Base's skipUnlessSupported()
check -- meaning $supportsUserLookup = false correctly skipped
testGetUserWithInvalidUsername but silently did nothing for testGetUser.
Renamed to testGetUserByUuid so it no longer overrides a Base test by
name, matching every other adapter's tests (none of which redefine a
Base method -- they only add new ones or override the hook methods
Base already exposes for this).
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
@HarshMN2345

Copy link
Copy Markdown
Member Author

@greptile Both flagged points are intentional, not gaps:

getEvent() only returning the first batched event — this is deliberate, not truncation. Bitbucket batches multiple ref changes into one webhook delivery; getEvent() keeps returning just the first to preserve the existing single-event contract every other adapter and all current callers rely on. The new getEvents() (see Adapter.php) returns the full set. This split is explicitly covered by BitbucketTest::testGetEventsReportsEveryPushedBranch, which asserts getEvent() returns $events[0] while getEvents() returns all of them.

Access token embedded in clone/archive URLs — this matches the pattern already established by the other adapters, not a new exposure:

generateCloneCommand: GitHub embeds :{token}@github.com, GitLab embeds oauth2:{token}@GitLab.... Bitbucket's x-token-auth:{token}@... is the same convention with Bitbucket's required auth username.
getRepositoryPresignedUrl: GitLab already returns the raw token as ?access_token={token} in this same method. GitHub is the outlier — it alone can return a genuinely short-lived signed redirect URL from its CDN. Bitbucket's archive endpoint answers directly rather than redirecting, and dropped the ?access_token= query form in CHANGE-3052, so the token goes in HTTP Basic userinfo instead — same exposure surface as GitLab, different transport. This tradeoff is called out explicitly in the PR description.
Neither is a regression introduced by this PR — both mirror decisions already shipped in GitHub.php/GitLab.php.

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Both points are well-taken — you're right on both counts.

getEvent() returning only the first ref: The getEvent()/getEvents() split is a clean solution to the batching problem. Returning $events[0] from getEvent() preserves the existing single-event contract without breaking any current callers, and testGetEventsReportsEveryPushedBranch explicitly covers both sides of that contract. No issue there.

Token in URLs: You're correct that this isn't a new exposure — GitLab's ?access_token={token} in getRepositoryPresignedUrl() and GitHub/GitLab's clone URL embedding both establish the same pattern. Bitbucket's HTTP Basic userinfo approach (x-token-auth:{token}@...) is just the transport Bitbucket requires for the same credential placement. The PR description already calls this out clearly. I withdraw both observations.

@HarshMN2345

Copy link
Copy Markdown
Member Author

@greptile re-review

findWebhookUuid()'s url match could return an older webhook sharing the
same delivery url instead of the one just created, silently handing the
caller the wrong uuid to manage. Picks the match with the latest
created_at instead of the first one found.

Also extracted two blocks duplicated verbatim elsewhere in this file:
the x-token-auth credential embedding shared by getRepositoryPresignedUrl()
and generateCloneCommand(), and the linked-vs-raw author name resolution
shared by parseCommit() and parsePushChange().
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
created_at can tie or race with a concurrent creation, so picking the
"newest" match was still a guess -- and a wrong guess is worse than the
original problem: the caller would later delete an unrelated webhook
while the one it actually just created stays orphaned and unmanaged.
findSingleWebhookByUrl() only returns a uuid when exactly one webhook
has that url; any ambiguity or none at all now surfaces a clear error
naming the repository and url to check manually, rather than silently
resolving to a webhook that might not be the right one.
…adapter

An exhaustive pass over the diff turned up more of what earlier rounds
already fixed piecemeal:

Duplication:
- resolveRef() and resolveDefaultBranch() repeated the same mainbranch
  lookup, differing only in the empty case -> mainBranchName().
- listSource() and getRepositoryContent() repeated the resolveRef/
  normalize/build-/src/-url preamble verbatim -> sourceUrl().
- normalizeRepository() and getEventRepositoryOwner() both derived the
  workspace slug with a full_name fallback -> workspaceSlugOf().
- searchRepositories() re-implemented the id/private/pushed_at mapping
  normalizeRepository() exists to centralize; it now runs through it.
- getRepositoryName() re-issued the request getRepository() already
  makes; it now splits the "workspace/slug" id and delegates.
- The "no numeric repository ids" note was stated six times; it is now
  stated once, in normalizeRepository() where the id is minted.

Dead code:
- setBitbucketUrl() had no caller anywhere.
- $refreshToken was assigned and never read.
- getEventRepositoryId() wrapped a single strval(); inlined.
- Base's $supportsPresignedUrls was never set false by any adapter, so
  both branches it guarded were unreachable.
- BitbucketTest read TESTS_BITBUCKET_ENDPOINT, which no compose file,
  workflow or doc sets.

Consistency with the sibling adapters:
- $body -> $responseBody, $SHA -> $commitHash.
- listRepositoryLanguages() returns [] for a missing repository rather
  than throwing, matching the other list-style getters.
- Dropped defensive is_array() ternaries where plain ?? [] is what
  GitHub/GitLab/Gitea write, and docblocks that restated the method
  name or the contract in Adapter.php.
- The inverted skipUnlessSupported(!$flag, ...) in Base now reads as a
  plain markTestSkipped with a message that says what is happening.
…us urls

Two correctness bugs the shared suite can't currently catch, because the
bitbucket CI job skips every test for want of credentials:

- getRepositoryName() returned Bitbucket's free-form display `name`, not
  the `slug` its API routes on and every other method here takes as
  $repositoryName. GitLab has the same split and deliberately returns
  `path`; on GitHub and Gitea the two are the same value, so Bitbucket
  was the one adapter that picked the non-routable field. Any repository
  whose display name isn't already slug-form ("My Site" vs "my-site")
  would 404 clone, branch and commit-status calls downstream. Base can't
  see it: createRepository() posts name == slug, so the two never differ
  for anything the suite creates.
- getRepositoryContent() reported the last-touching commit hash as `sha`,
  where Base::testGetRepositoryContentReportsBlobSha (ungated, shared)
  asserts it is the git blob id, and GitHub/GitLab/Gitea all return a
  real one. The adapter has the bytes, so it now computes the blob id
  git itself stores rather than substituting a different hash.

Also:
- Adapter::getEvents() default returned [[]] for an event the adapter
  doesn't report, where an overriding adapter returns []. It now drops
  the empty event so the two agree.
- Base asserted a numeric webhook id in one of the three places it
  checks one, left over from before the contract widened to int|string.
- Bitbucket's bespoke commit-status test re-ran Base::testGetCommitStatuses
  verbatim to change one assertion; Base now exposes an
  assertCommitStatusUrl() hook (no-op default) that BitbucketTest fills
  in, dropping a whole repository round trip.
- Inlined the last single-use helpers (resolveRef and encodeRepositoryPath
  into sourceUrl, resolveDefaultBranch into createFile,
  getEventRepositoryOwner into its two callers).
Comment thread src/VCS/Adapter/Git/Bitbucket.php Outdated
Comment on lines +1035 to +1037
if (($responseHeaders['status-code'] ?? 0) >= 400) {
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial hook list selects wrong UUID

When a later page of the hook listing fails after an earlier page contains one matching URL, this branch treats that partial result as complete and returns the older hook's UUID. Subsequent cleanup deletes the older webhook while the newly created webhook remains active and unmanaged.

Suggested change
if (($responseHeaders['status-code'] ?? 0) >= 400) {
break;
}
if (($responseHeaders['status-code'] ?? 0) >= 400) {
return null;
}

Knowledge Base Used: VCS Core Adapter Framework

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/VCS/Adapter/Git/Bitbucket.php
Line: 1035-1037

Comment:
**Partial hook list selects wrong UUID**

When a later page of the hook listing fails after an earlier page contains one matching URL, this branch treats that partial result as complete and returns the older hook's UUID. Subsequent cleanup deletes the older webhook while the newly created webhook remains active and unmanaged.

```suggestion
            if (($responseHeaders['status-code'] ?? 0) >= 400) {
                return null;
            }
```

**Knowledge Base Used:** [VCS Core Adapter Framework](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/vcs/-/docs/vcs-core-adapter.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Reverts everything that wasn't Bitbucket's to change:

- deleteWebhook() is gone entirely. It had no caller anywhere -- not in
  Appwrite (RepositoryWebhooks only ever creates), not in cloud, and not
  in the suite except the tests written to exercise it. Test cleanup
  didn't need it either, since discardRepositories() deletes the whole
  repository. With it goes the uuid-recovery fallback: createWebhook()
  throws again when Bitbucket omits the uuid, as GitHub.php does, and
  Base loses $supportsWebhookCreation and the create/delete test, which
  existed only to drive it.
- createWebhook() moves back to Git.php where it was; only its return
  type widens to int|string, which Bitbucket needs to satisfy the
  existing contract at all.
- GitHub.php, GitLab.php, Gitea.php and GitHubTest.php are untouched
  again, as is the README table for the other providers.
- Dropped testUpdateCommitStatusDefaultsUrlToCommit rather than keeping
  it or moving it into Base behind a hook. Base::testGetCommitStatuses
  already writes a status with an empty target_url and reads it back, so
  it already covers the defaulting for Bitbucket; the extra test only
  pinned which url was substituted, at the cost of repeating the whole
  repository round trip.

What still touches shared files is what Bitbucket cannot run without:
getEvents() on Adapter (its push batches refs), and in Base the
repositoryIdOf() hook, the $reportsAffectedFilesInPushEvent flag, the
self:: -> static:: reads so an adapter can restate an EVENT_* fact, and
two skips for capabilities Bitbucket lacks.
An OAuth consumer is free where a workspace access token may not be, and
the token it hands back lasts two hours, so CI mints one before starting
the stack rather than keeping a long-lived credential around. The value
goes straight into GITHUB_ENV, masked, and is never stored as a secret --
only the consumer's client id and secret are, since minting has to
authenticate as the consumer.

With no consumer configured the step is a no-op and the suite skips, the
same as it does today.

Also documents in CONTRIBUTING why an Atlassian account API token (the
ATATT kind) answers 401 here: it authenticates as email:token over HTTP
Basic, and the adapter sends the token as a Bearer credential.
curl -sf discards the error body, so a refused token request surfaced as
a bare "could not mint" with nothing to act on. Captures the status and
body instead and echoes Bitbucket's own error_description.
createRepository() reported only the status code, so a 400 gave nothing
to act on. createWebhook() already includes the response body; this
matches it.
CI mints its own token, so the manual steps were setup lore rather than
something a contributor needs in the repo.
EVENT_REPOSITORY_ID is the one EVENT_* fact an adapter overrides, so it
is the only one that needs late static binding to resolve. The other
eight were changed for consistency and are churn this PR doesn't need.
createFile() defaulted an empty repository's branch to 'main' on the
belief that Bitbucket names the first branch after whatever the commit
asks for. It doesn't -- the commit lands on Bitbucket's own default and
'main' never exists, so every later call that named it failed: 404 from
getLatestCommit(), 400 from createBranch() resolving it as a target, and
empty results from listSource(), which swallows the 404 into [].

It only ever surfaced now because the suite skipped for want of
credentials until this run.

Omits the branch when there is none to name, letting Bitbucket create
its default, and declares that default as 'master' in BitbucketTest the
way GogsTest already does for Gogs.
The adapter only spoke Bearer, so an Atlassian account API token -- the
ATATT kind, which authenticates as email:token over HTTP Basic -- answered
401 on every call, and testing meant standing up an OAuth consumer with
the client-credentials grant enabled.

An email:token pair always carries a colon and a bare token never does, so
the credential names its own scheme and initializeVariables() keeps its
signature. Centralising the header also collapses 26 copies of
'Bearer ' . $this->accessToken into one place, and clone/archive URLs
reuse the same distinction: an email:token pair is already valid userinfo,
where a bare token needs the x-token-auth username Bitbucket pairs it with.

CI drops the minting step it needed for client credentials and passes
TESTS_BITBUCKET_ACCESS_TOKEN straight through again.
A bare token and an email:token pair fail very differently, and the 401
they produce looks the same from the outside. Reports the shape -- never
the value -- so a misformed secret is obvious rather than inferred.
The endpoint answers with workspace_access objects that carry the
workspace under its own key:

  {"type":"workspace_access","administrator":true,
   "workspace":{"slug":"utopiavcs", ...}}

getOwnerName() read $values[0]['slug'], which is never set, so it always
fell through to the account handle -- and Bitbucket's repository API
does not accept a handle as a workspace, so every call built from it
answered 404. The nested read was there originally; I removed it while
trimming comments, on the mistaken belief that it covered an endpoint
this code never calls. It is the only shape this endpoint returns.
A minting step was overwriting TESTS_BITBUCKET_ACCESS_TOKEN with a token
minted from the OAuth consumer, so the configured credential was never
used: CI authenticated as the consumer, resolved the consumer's
workspace, and answered 403 against any other. Removes it, along with
the credential-shape check that existed only to debug that confusion.
The endpoint rejects a page parameter -- "Invalid page", HTTP 400 -- and
pages instead by handing back the url of the next page. listSource()
asked for ?pagelen=100&page=1, so every listing failed, and because it
read any error as an empty listing the failure surfaced as
getRepositoryTree() and listRepositoryContents() quietly returning
nothing at all.

Follows the next url as given, and reports a failed listing rather than
answering it with an empty array: only a 404, meaning the ref or path
genuinely isn't there, is still an empty result. Silently swallowing the
400 is what kept this hidden.
getRepositoryTree() answered nothing for a branch like feature/test, because
sourceUrl() percent-encoded the ref and Bitbucket matches a nested branch
name against the path as written -- feature%2Ftest names no ref.

getRepositoryPresignedUrl() already kept its slashes for exactly this
reason, so the three places a ref reaches a path now share one encodeRef()
rather than two of them disagreeing. getLatestCommit() had the same fault
with no test to catch it.
getRepositoryTree() answered nothing for a branch like feature/test.
Bitbucket takes the ref as one path segment, so the name read as the ref
`feature` and the path `test`; percent-encoding the slash didn't separate
them either, as the endpoint matches the ref against the path as written.

The three places a ref reaches a url -- source listings, latest commit and
the archive url -- now resolve a nested name to its hash first, which names
the same commit with nothing left to misread. Names without a slash are
passed through untouched, so the usual case still costs one request.
Comment thread src/VCS/Adapter.php Outdated
Comment on lines +238 to +245
public function getEvents(string $event, string $payload): array
{
$parsed = $this->getEvent($event, $payload);

// An event the adapter doesn't report describes nothing, so report
// nothing rather than one empty event
return $parsed === [] ? [] : [$parsed];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate PR, Rename everything to "getEvents". So getEvent no longer exists

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FOllowup appwrite PR to consume it as array and loop it

Comment thread tests/VCS/Base.php Outdated
Comment on lines +294 to +300
protected function repositoryIdOf(array $repository): string
{
$this->assertArrayHasKey('id', $repository);
$this->assertIsNumeric($repository['id']);

return (string) $repository['id'];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid

Comment thread tests/VCS/Adapter/BitbucketTest.php Outdated
protected static string $pullRequestEventName = 'pullrequest:created';

protected static bool $supportsInstallationRepository = false;
protected static bool $supportsCheckRuns = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be supported

Bitbucket has no checks api, but it has the build statuses a check run is
reported through, so a run maps onto one: the key identifies it, the name
names it, and the state carries how it went.

A check run was addressed by an int, which Bitbucket has nothing to answer
with -- a status is identified by its key under a commit, and carries no
number. The id is a string now, and Bitbucket writes the commit into the one
it hands out so a run stays reachable from the id alone. GitHub reports the
number it already had as that string, so nothing it returns moves.

Bitbucket holds four states where a check run has seven verdicts, so
neutral, skipped and cancelled all stop a run and a run read back reports
its state's verdict rather than the one it was written with.
The id came from a hook the adapters overrode, which named a pattern two
call sites don't need. Both read it off the repository as it comes.
Trims the prose around the check runs and the ref lookup to the one or two
lines the rest of the file spends, dropping what restated the code and the
url note updateCommitStatus already carries.
The id names a workspace and a slug either side of a slash, and travels as
one path segment, so callers encode that slash. Nothing decoded it, leaving
getRepositoryName() to look for a separator that was no longer there and
report every Bitbucket repository as missing.

An id that arrives with its slash intact carries no escapes, so it comes
back through unchanged.
main now asks every adapter for the events a delivery describes, so the
wrapper this branch carried and the single-event read Bitbucket kept beside
it both describe what the contract already says.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants